Skip to content

feat(fsdp): meta-init + rank0-streamed weight loading (--fsdp-load-mode stream) - #39

Merged
Rockdu merged 10 commits into
mainfrom
fix/fsdp-meta-stream-load
Jul 20, 2026
Merged

feat(fsdp): meta-init + rank0-streamed weight loading (--fsdp-load-mode stream)#39
Rockdu merged 10 commits into
mainfrom
fix/fsdp-meta-stream-load

Conversation

@zhihengy

@zhihengy zhihengy commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Overview

This PR replaces per-rank full-model materialization with rank-0-authoritative, FSDP2-sharded loading. Rank 0 loads real CPU weights; other ranks build meta shells, allocate only their local shards, and receive weights from rank 0. This removes the old full-model H2D copy on every rank, substantially reducing initialization latency and peak GPU memory while preserving bitwise-equivalent base-weight shards.

End-to-end loading flow

  1. Load the scheduler independently, then process each trainable component separately.
  2. Rank 0 uses diffusers' low_cpu_mem_usage=True path and holds the authoritative real CPU weights. Other ranks run under init_empty_weights(include_buffers=False) with low_cpu_mem_usage=False, so parameters remain on meta; their checkpoint state dict may still be read transiently on CPU. LoRA is initialized only on rank 0, while other ranks build meta adapters.
  3. Verify that every non-rank-0 parameter is meta, then broadcast rank 0's per-tensor parameter and buffer dtypes so all ranks have an identical sharding layout, including fp32-pinned modules.
  4. Capture rank 0's full state, apply FSDP2, move rank 0's real local shards to CUDA, and use to_empty(cuda) to allocate only the other ranks' local shards. set_model_state_dict(..., broadcast_from_rank0=True) fills those shards.
  5. Broadcast every named buffer from rank 0, then run model-specific post-materialization hooks. With CPU offload, parameters return to CPU while buffers remain on CUDA for forward.

Important edge cases and fixes

  • Meta init vs. low_cpu_mem_usage: non-rank-0 must use False; diffusers' True path materializes parameters by assignment and bypasses the ambient meta context.
  • _keep_in_fp32_modules: diffusers rejects low_cpu_mem_usage=False for classes with fp32-pinned modules. The pin is temporarily cleared only on non-rank-0; rank 0 still honors it, and sync_model_dtypes restores the authoritative dtype for every meta parameter before sharding.
  • Buffers: include_buffers=False lets constructors compute real buffers, but to_empty discards their values and non-persistent buffers are absent from state_dict. Broadcasting all buffers after parameter loading restores correct, replicated values on every rank.
  • Wan optional components: passing transformer=None or transformer_2=None through DiffusionPipeline.from_pretrained does not reliably skip them because Wan declares them as optional defaults; the None entries are dropped and both 14B experts can be loaded unintentionally, including during scheduler loading. The fix resolves the requested class from model_index.json and calls cls.from_pretrained(..., subfolder=component) directly, so only the named component is touched and the fp32 workaround targets the correct class.

Benchmark: meta-init + rank0-broadcast loading vs main (8x H200, cold page cache)

Measured on the real FSDPTrainRayActor.init model-loading path;
checkpoint page cache evicted (posix_fadvise DONTNEED) before every run;
latency and peak memory are the max across ranks.
Per-rank shard sha256 digests are bitwise-identical between the two
modes for all four families (same world size, same sharding plan, so the
local shards must match exactly).

model init latency main init latency stream speedup peak GPU/rank main peak GPU/rank stream GPU cut peak host RSS main peak host RSS stream
SD3.5-medium 37.2 s 15.8 s 2.4x 9.3 GB 2.9 GB -69% 40.1 GB 16.2 GB
Qwen-Image 247.6 s 120.2 s 2.1x 76.3 GB 10.9 GB -86% 80.2 GB 80.2 GB
Wan2.2-A14B (2 experts) 377.0 s 133.2 s 2.8x 60.1 GB 14.6 GB -76% 11.6 GB 54.8 GB
LTX-2.3 68.2 s 56.3 s 1.2x 35.5 GB 5.2 GB -85% 37.0 GB 37.0 GB

Init latency (s) — lower is better

xychart-beta
    title "init latency (s): main (back) vs stream (front)"
    x-axis ["SD3.5-medium", "Qwen-Image", "Wan2.2-A14B (2 experts)", "LTX-2.3"]
    y-axis "seconds" 0 --> 434
    bar [37.2, 247.6, 377.0, 68.2]
    bar [15.8, 120.2, 133.2, 56.3]
Loading

Peak GPU memory per rank (GB) — lower is better

xychart-beta
    title "peak GPU/rank (GB): main (back) vs stream (front)"
    x-axis ["SD3.5-medium", "Qwen-Image", "Wan2.2-A14B (2 experts)", "LTX-2.3"]
    y-axis "GB" 0 --> 88
    bar [9.3, 76.3, 60.1, 35.5]
    bar [2.9, 10.9, 14.6, 5.2]
Loading
xychart-beta
    title "init speedup (x, higher is better)"
    x-axis ["SD3.5-medium", "Qwen-Image", "Wan2.2-A14B (2 experts)", "LTX-2.3"]
    y-axis "speedup (x)" 0 --> 3
    bar [2.4, 2.1, 2.8, 1.2]
Loading
xychart-beta
    title "peak GPU/rank reduction (%)"
    x-axis ["SD3.5-medium", "Qwen-Image", "Wan2.2-A14B (2 experts)", "LTX-2.3"]
    y-axis "% reduction" 0 --> 99
    bar [68.8, 85.7, 75.7, 85.4]
Loading

Loading mechanics: low_cpu_mem_usage, meta init, and _keep_in_fp32_modules

This is the most confusing corner of the PR, so spelling it out.

diffusers' from_pretrained has two loading paths:

  • low_cpu_mem_usage=True (diffusers' default): self-contained fast path.
    diffusers builds the module on its own internal meta device, then reads the
    checkpoint and materializes every param directly by assignment. It always
    produces real weights and bypasses any ambient init context (assignment does
    not go through parameter registration, so an external init_empty_weights
    cannot intercept it). Peak CPU ~= 1x model size.
  • low_cpu_mem_usage=False: plain path. Runs the model's ordinary
    __init__ (params allocated through normal registration), then reads the
    full state dict and copy_()s it into the params. In normal use this peaks
    at ~2x model size -- that is exactly what True was invented to avoid.

Why each rank uses what it uses:

rank ambient context low_cpu_mem_usage outcome
0 none True real weights on CPU, 1x peak, fp32 pins honored
1..N-1 init_empty_weights False (forced) __init__ allocation is hijacked -> params stay on meta (0 bytes); the state-dict read still happens (transient CPU) but copy_() into meta tensors is a no-op and the dict is freed. Net result: zero-memory shells

Non-rank0 must use False: it is the only path whose construction step can
be intercepted by our ambient meta context. With True, diffusers would hand
back fully materialized weights on every rank and the meta-shell design (shard
on meta -> to_empty only this rank's shard on GPU -> broadcast from rank0)
would silently degenerate into N full copies; the actor guards this with an
explicit "did not honor meta initialization" check.

The conflict: some model classes (e.g. WanTransformer3DModel) declare
_keep_in_fp32_modules -- submodules that must stay fp32 even when the rest
loads in bf16. diffusers implements that pinning only inside the True
path
, and rather than silently dropping the pin it raises up-front:
ValueError: low_cpu_mem_usage cannot be False when keep_in_fp32_modules is True. So non-rank0 (forced False) cannot load such a class as-is.

How we handle it: on non-rank0 only, _keep_in_fp32_modules is temporarily
cleared on the class for the duration of the from_pretrained call. This is
safe because non-rank0 params are meta placeholders anyway -- authoritative
dtypes come from rank0 (which loads with True and honors the pin), and
sync_model_dtypes broadcasts rank0's per-tensor dtypes to every rank before
sharding, so all shards agree.

zhihengy and others added 9 commits July 20, 2026 20:46
…de stream)

Every rank used to materialize the full pipeline on CPU
(DiffusionPipeline.from_pretrained), move the whole unsharded model to GPU,
and only then fully_shard — peak GPU = full model (54 GB for one Wan2.2-A14B
expert in fp32 master dtype), N×full disk reads, and no way to honor a meta
init context (diffusers' pipeline loader crashes under one).

New default path (--fsdp-load-mode stream), per component:
  build on meta via init_empty_weights(include_buffers=False)  # buffers real
  -> fully_shard on meta (costs nothing)
  -> to_empty: allocate only this rank's shards; carry non-persistent
     buffers (Wan rope tables) across the transition
  -> rank0 streams safetensors file-by-file, set_model_state_dict
     broadcast_from_rank0 scatters into the shards
  -> LoRA adapters build on meta too (low_cpu_mem_usage) and initialize
     after materialization; data-aware inits (pissa/olora) are rejected
  -> family postprocess hook runs the before-fsdp hook after
     materialization, where qwen-image's rope parity rebuild has real
     CUDA tensors instead of silently no-oping on meta

--fsdp-load-mode legacy keeps the old path for custom pipelines.

Wan2.2-A14B (both experts, fp32, 2×H200): init 285s -> 71s, peak GPU
80.5 -> 54.0 GB (= the sharded params themselves); only rank0 reads the
checkpoint. Streamed weights verified bitwise-equal to from_pretrained
(params + buffers) at 1.3B scale and in the new 2-GPU CI test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rank0 keeps its real shards through .to(cuda) instead of to_empty +
re-fill; every buffer is broadcast from rank0 after set_model_state_dict
(covers non-persistent buffers absent from any state_dict, instead of
trusting recomputed-from-config values); under fsdp_cpu_offload the load
happens on GPU and buffers stay there, since CPUOffloadPolicy manages
params only. Add short specs to the three loading helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Keep the loading interface (load_component/load_scheduler) enforced at
class-definition time rather than first call; the conditionally-required
attention hooks stay NotImplementedError as before. Test-local subclasses
stub the abstract loaders themselves (also drops the stale
load_models_and_scheduler stubs left from the old interface).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This reverts commit 671b9b41; the CI test is still wanted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…blings

WanPipeline declares transformer/transformer_2 optional with a None default,
which drops them from expected_modules: the None passed to
DiffusionPipeline.from_pretrained is silently ignored and both 14B experts
load from disk on every rank -- during load_scheduler too -- and on non-rank0
(low_cpu_mem_usage=False) trip diffusers' _keep_in_fp32_modules guard,
crashing any multi-rank init. Resolve the component class from
model_index.json and from_pretrained(subfolder=...) it directly instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zhihengy
zhihengy force-pushed the fix/fsdp-meta-stream-load branch 2 times, most recently from a99b58d to ed20fd0 Compare July 20, 2026 21:09
@zhihengy
zhihengy force-pushed the fix/fsdp-meta-stream-load branch from ed20fd0 to d431a23 Compare July 20, 2026 21:12
@zhihengy
zhihengy marked this pull request as ready for review July 20, 2026 21:13

@Rockdu Rockdu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, approved!

@Rockdu
Rockdu merged commit 212db15 into main Jul 20, 2026
11 checks passed
zhihengy added a commit that referenced this pull request Jul 22, 2026
Resolves conflicts with the ModelBackend refactor (#22/#39):
- actor.py: keep main's load_scheduler/load_component meta-init path,
  thread --fsdp-frozen-params-dtype into load_component's master_dtype;
  keep cast_forward_inputs on apply_fsdp2
- sglang_diffusion_engine.py: keep the num_gpus/tp_size mapping (engine
  owns its rollout allocation; tp comes from --sglang-tp-size)
- cosmos3.py: drop Cosmos3ModelBackend — the default DiffusersModelBackend
  now loads per-component from model_index.json (Cosmos3OmniTransformer /
  UniPCMultistepScheduler resolve cleanly); move the UND-freeze +
  time_embedder cast hook to postprocess_model_after_materialize, the
  renamed hook site (after FSDP wrap + weight materialization)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants